D:\a\csshw\csshw\xtask\src\release.rs
Line | Count | Source |
1 | | //! Release preparation and git tag creation. |
2 | | //! |
3 | | //! [`prepare_release`] bumps the version, optionally creates a maintenance |
4 | | //! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog, |
5 | | //! commits, and pushes. |
6 | | //! |
7 | | //! [`create_release_tag`] validates the current state and creates an annotated |
8 | | //! git tag that triggers the GitHub Actions release workflow. |
9 | | |
10 | | use anyhow::{bail, Context, Result}; |
11 | | use semver::Version; |
12 | | |
13 | | /// Type of version increment for a release. |
14 | | #[derive(Debug, PartialEq)] |
15 | | pub enum ReleaseType { |
16 | | /// Increment the major component (X.0.0). |
17 | | Major, |
18 | | /// Increment the minor component (0.X.0). |
19 | | Minor, |
20 | | /// Increment the patch component (0.0.X). |
21 | | Patch, |
22 | | } |
23 | | |
24 | | impl std::fmt::Display for ReleaseType { |
25 | 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
26 | 18 | match self { |
27 | 0 | ReleaseType::Major => write!(f, "major"), |
28 | 15 | ReleaseType::Minor => write!(f, "minor"), |
29 | 3 | ReleaseType::Patch => write!(f, "patch"), |
30 | | } |
31 | 18 | } |
32 | | } |
33 | | |
34 | | /// All side-effecting operations required by this module. |
35 | | /// |
36 | | /// Each method maps to exactly one external operation, making every step |
37 | | /// independently mockable in tests. |
38 | | pub trait ReleaseSystem { |
39 | | /// Run `git status --porcelain` and return its stdout. |
40 | | /// |
41 | | /// # Errors |
42 | | /// |
43 | | /// Returns an error if the process fails. |
44 | | fn git_status_porcelain(&self) -> Result<String>; |
45 | | |
46 | | /// Return the current git branch name. |
47 | | /// |
48 | | /// # Errors |
49 | | /// |
50 | | /// Returns an error if the process fails. |
51 | | fn git_current_branch(&self) -> Result<String>; |
52 | | |
53 | | /// Create and switch to a new branch with `git checkout -b <name>`. |
54 | | /// |
55 | | /// # Arguments |
56 | | /// |
57 | | /// * `name` - Branch name to create. |
58 | | /// |
59 | | /// # Errors |
60 | | /// |
61 | | /// Returns an error if the process fails. |
62 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()>; |
63 | | |
64 | | /// Switch to an existing branch with `git checkout <name>`. |
65 | | /// |
66 | | /// Relies on git's DWIM behaviour: when `<name>` exists only as |
67 | | /// `refs/remotes/origin/<name>`, a local tracking branch is created. |
68 | | /// The caller is responsible for fetching beforehand. |
69 | | /// |
70 | | /// # Arguments |
71 | | /// |
72 | | /// * `name` - Branch name to switch to. |
73 | | /// |
74 | | /// # Errors |
75 | | /// |
76 | | /// Returns an error if the process fails. |
77 | | fn git_checkout(&self, name: &str) -> Result<()>; |
78 | | |
79 | | /// Return `true` when `refs/heads/<name>` exists locally. |
80 | | /// |
81 | | /// # Arguments |
82 | | /// |
83 | | /// * `name` - Local branch name to look up. |
84 | | /// |
85 | | /// # Errors |
86 | | /// |
87 | | /// Returns an error if the process fails for a reason other than the ref |
88 | | /// not existing. |
89 | | fn git_branch_exists_local(&self, name: &str) -> Result<bool>; |
90 | | |
91 | | /// Return `true` when `refs/remotes/origin/<name>` exists locally. |
92 | | /// |
93 | | /// The remote ref is only present after a successful `git fetch`, so |
94 | | /// callers must fetch before relying on this answer to reflect the |
95 | | /// remote's actual state. |
96 | | /// |
97 | | /// # Arguments |
98 | | /// |
99 | | /// * `name` - Branch name to look up under `origin/`. |
100 | | /// |
101 | | /// # Errors |
102 | | /// |
103 | | /// Returns an error if the process fails for a reason other than the ref |
104 | | /// not existing. |
105 | | fn git_branch_exists_origin(&self, name: &str) -> Result<bool>; |
106 | | |
107 | | /// Stage the given files with `git add`. |
108 | | /// |
109 | | /// # Arguments |
110 | | /// |
111 | | /// * `files` - Paths to stage. |
112 | | /// |
113 | | /// # Errors |
114 | | /// |
115 | | /// Returns an error if the process fails. |
116 | | fn git_add(&self, files: &[String]) -> Result<()>; |
117 | | |
118 | | /// Commit staged changes with the given message. |
119 | | /// |
120 | | /// # Arguments |
121 | | /// |
122 | | /// * `message` - Commit message. |
123 | | /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks. |
124 | | /// |
125 | | /// # Errors |
126 | | /// |
127 | | /// Returns an error if the process fails. |
128 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>; |
129 | | |
130 | | /// Run `git push` with the given extra arguments. |
131 | | /// |
132 | | /// # Arguments |
133 | | /// |
134 | | /// * `args` - Extra arguments appended to `git push`. |
135 | | /// |
136 | | /// # Errors |
137 | | /// |
138 | | /// Returns an error if the process fails. |
139 | | fn git_push(&self, args: &[String]) -> Result<()>; |
140 | | |
141 | | /// Open a GitHub pull request against `base` using `gh pr create --fill`. |
142 | | /// |
143 | | /// `--fill` derives title and body from the latest commit, so the caller |
144 | | /// must ensure that commit has the desired subject/body. |
145 | | /// |
146 | | /// # Arguments |
147 | | /// |
148 | | /// * `base` - Branch the PR targets. |
149 | | /// |
150 | | /// # Errors |
151 | | /// |
152 | | /// Returns an error if the process fails. |
153 | | fn gh_pr_create(&self, base: &str) -> Result<()>; |
154 | | |
155 | | /// Return `git tag -l <tag>` stdout for the given tag name. |
156 | | /// |
157 | | /// # Arguments |
158 | | /// |
159 | | /// * `tag` - Tag name to check. |
160 | | /// |
161 | | /// # Errors |
162 | | /// |
163 | | /// Returns an error if the process fails. |
164 | | fn git_tag_list(&self, tag: &str) -> Result<String>; |
165 | | |
166 | | /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`). |
167 | | /// |
168 | | /// # Errors |
169 | | /// |
170 | | /// Returns an error if the process fails. |
171 | | fn git_log_latest_subject(&self) -> Result<String>; |
172 | | |
173 | | /// Run `git fetch`. |
174 | | /// |
175 | | /// # Errors |
176 | | /// |
177 | | /// Returns an error if the process fails (non-fatal; callers may continue). |
178 | | fn git_fetch(&self) -> Result<()>; |
179 | | |
180 | | /// Return the number of commits the local branch is behind `<branch>` on |
181 | | /// the remote. |
182 | | /// |
183 | | /// # Arguments |
184 | | /// |
185 | | /// * `branch` - Remote branch to compare against. |
186 | | /// |
187 | | /// # Errors |
188 | | /// |
189 | | /// Returns an error if the process fails. |
190 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>; |
191 | | |
192 | | /// Return the number of commits the local branch is ahead of `<branch>` |
193 | | /// on the remote. |
194 | | /// |
195 | | /// # Arguments |
196 | | /// |
197 | | /// * `branch` - Remote branch to compare against. |
198 | | /// |
199 | | /// # Errors |
200 | | /// |
201 | | /// Returns an error if the process fails. |
202 | | fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32>; |
203 | | |
204 | | /// Create an annotated git tag. |
205 | | /// |
206 | | /// # Arguments |
207 | | /// |
208 | | /// * `tag` - Tag name. |
209 | | /// * `message` - Annotation message. |
210 | | /// |
211 | | /// # Errors |
212 | | /// |
213 | | /// Returns an error if the process fails. |
214 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>; |
215 | | |
216 | | /// Push a tag to `origin`. |
217 | | /// |
218 | | /// # Arguments |
219 | | /// |
220 | | /// * `tag` - Tag name to push. |
221 | | /// |
222 | | /// # Errors |
223 | | /// |
224 | | /// Returns an error if the process fails. |
225 | | fn git_push_tag(&self, tag: &str) -> Result<()>; |
226 | | |
227 | | /// Read the contents of `Cargo.toml`. |
228 | | /// |
229 | | /// # Errors |
230 | | /// |
231 | | /// Returns an error if the file cannot be read. |
232 | | fn read_cargo_toml(&self) -> Result<String>; |
233 | | |
234 | | /// Write `content` to `Cargo.toml`. |
235 | | /// |
236 | | /// # Errors |
237 | | /// |
238 | | /// Returns an error if the write fails. |
239 | | fn write_cargo_toml(&self, content: &str) -> Result<()>; |
240 | | |
241 | | /// Run `cargo update --workspace` to refresh `Cargo.lock`. |
242 | | /// |
243 | | /// # Errors |
244 | | /// |
245 | | /// Returns an error if the process fails. |
246 | | fn cargo_update_workspace(&self) -> Result<()>; |
247 | | |
248 | | /// Generate the changelog for the current version. |
249 | | /// |
250 | | /// # Errors |
251 | | /// |
252 | | /// Returns an error if changelog generation fails. |
253 | | fn generate_changelog(&self) -> Result<()>; |
254 | | |
255 | | /// Display `message` and read a line of user input. |
256 | | /// |
257 | | /// # Arguments |
258 | | /// |
259 | | /// * `message` - Prompt text. |
260 | | /// |
261 | | /// # Returns |
262 | | /// |
263 | | /// The trimmed response string. |
264 | | /// |
265 | | /// # Errors |
266 | | /// |
267 | | /// Returns an error if stdin cannot be read. |
268 | | fn prompt_user(&self, message: &str) -> Result<String>; |
269 | | } |
270 | | |
271 | | /// Check whether `ref_name` exists via `git show-ref --verify`. |
272 | | /// |
273 | | /// `git show-ref` is documented to exit 0 when the ref exists, 1 when it does |
274 | | /// not, and other non-zero codes for actual failures (bad arguments, broken |
275 | | /// repo, etc.). Mapping every non-zero exit to "missing" would silently |
276 | | /// swallow real errors, so the latter must surface as an `Err`. |
277 | | #[cfg_attr(coverage_nightly, coverage(off))] |
278 | | fn show_ref_exists(ref_name: &str) -> Result<bool> { |
279 | | let output = std::process::Command::new("git") |
280 | | .args(["show-ref", "--verify", "--quiet", ref_name]) |
281 | | .output() |
282 | | .context("failed to run `git show-ref`")?; |
283 | | match output.status.code() { |
284 | | Some(0) => Ok(true), |
285 | | Some(1) => Ok(false), |
286 | | _ => bail!( |
287 | | "`git show-ref --verify {ref_name}` failed with status {}: {}", |
288 | | output.status, |
289 | | String::from_utf8_lossy(&output.stderr).trim(), |
290 | | ), |
291 | | } |
292 | | } |
293 | | |
294 | | /// Run `git rev-list --count <range>` and return the parsed commit count. |
295 | | /// |
296 | | /// A non-zero exit (e.g. unknown ref) or unparseable stdout must surface as an |
297 | | /// `Err` - returning `0` would silently mask "stale ref" or "git failed" as |
298 | | /// "branch is up to date". |
299 | | #[cfg_attr(coverage_nightly, coverage(off))] |
300 | | fn rev_list_count(range: &str) -> Result<u32> { |
301 | | let output = std::process::Command::new("git") |
302 | | .args(["rev-list", "--count", range]) |
303 | | .output() |
304 | | .context("failed to run `git rev-list`")?; |
305 | | if !output.status.success() { |
306 | | bail!( |
307 | | "`git rev-list --count {range}` failed with status {}: {}", |
308 | | output.status, |
309 | | String::from_utf8_lossy(&output.stderr).trim(), |
310 | | ); |
311 | | } |
312 | | let stdout = String::from_utf8_lossy(&output.stdout); |
313 | | stdout.trim().parse::<u32>().with_context(|| { |
314 | | format!("failed to parse `git rev-list --count {range}` stdout: {stdout:?}") |
315 | | }) |
316 | | } |
317 | | |
318 | | /// Production implementation of [`ReleaseSystem`]. |
319 | | pub struct RealSystem; |
320 | | |
321 | | #[cfg_attr(coverage_nightly, coverage(off))] |
322 | | impl ReleaseSystem for RealSystem { |
323 | | fn git_status_porcelain(&self) -> Result<String> { |
324 | | let output = std::process::Command::new("git") |
325 | | .args(["status", "--porcelain"]) |
326 | | .output() |
327 | | .context("failed to run `git status --porcelain`")?; |
328 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
329 | | } |
330 | | |
331 | | fn git_current_branch(&self) -> Result<String> { |
332 | | let output = std::process::Command::new("git") |
333 | | .args(["branch", "--show-current"]) |
334 | | .output() |
335 | | .context("failed to run `git branch --show-current`")?; |
336 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
337 | | } |
338 | | |
339 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()> { |
340 | | let status = std::process::Command::new("git") |
341 | | .args(["checkout", "-b", name]) |
342 | | .status() |
343 | | .context("failed to run `git checkout -b`")?; |
344 | | if !status.success() { |
345 | | bail!("`git checkout -b {name}` failed with status {status}"); |
346 | | } |
347 | | Ok(()) |
348 | | } |
349 | | |
350 | | fn git_checkout(&self, name: &str) -> Result<()> { |
351 | | let status = std::process::Command::new("git") |
352 | | .args(["checkout", name]) |
353 | | .status() |
354 | | .context("failed to run `git checkout`")?; |
355 | | if !status.success() { |
356 | | bail!("`git checkout {name}` failed with status {status}"); |
357 | | } |
358 | | Ok(()) |
359 | | } |
360 | | |
361 | | fn git_branch_exists_local(&self, name: &str) -> Result<bool> { |
362 | | show_ref_exists(&format!("refs/heads/{name}")) |
363 | | } |
364 | | |
365 | | fn git_branch_exists_origin(&self, name: &str) -> Result<bool> { |
366 | | show_ref_exists(&format!("refs/remotes/origin/{name}")) |
367 | | } |
368 | | |
369 | | fn git_add(&self, files: &[String]) -> Result<()> { |
370 | | let status = std::process::Command::new("git") |
371 | | .arg("add") |
372 | | .args(files) |
373 | | .status() |
374 | | .context("failed to run `git add`")?; |
375 | | if !status.success() { |
376 | | bail!("`git add` failed with status {status}"); |
377 | | } |
378 | | Ok(()) |
379 | | } |
380 | | |
381 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> { |
382 | | let mut cmd = std::process::Command::new("git"); |
383 | | cmd.args(["commit", "-m", message]); |
384 | | if no_verify { |
385 | | cmd.arg("--no-verify"); |
386 | | } |
387 | | let status = cmd.status().context("failed to run `git commit`")?; |
388 | | if !status.success() { |
389 | | bail!("`git commit` failed with status {status}"); |
390 | | } |
391 | | Ok(()) |
392 | | } |
393 | | |
394 | | fn git_push(&self, args: &[String]) -> Result<()> { |
395 | | let status = std::process::Command::new("git") |
396 | | .arg("push") |
397 | | .args(args) |
398 | | .status() |
399 | | .context("failed to run `git push`")?; |
400 | | if !status.success() { |
401 | | bail!("`git push` failed with status {status}"); |
402 | | } |
403 | | Ok(()) |
404 | | } |
405 | | |
406 | | fn gh_pr_create(&self, base: &str) -> Result<()> { |
407 | | let status = std::process::Command::new("gh") |
408 | | .args(["pr", "create", "--base", base, "--fill"]) |
409 | | .status() |
410 | | .context("failed to run `gh pr create`")?; |
411 | | if !status.success() { |
412 | | bail!("`gh pr create --base {base}` failed with status {status}"); |
413 | | } |
414 | | Ok(()) |
415 | | } |
416 | | |
417 | | fn git_tag_list(&self, tag: &str) -> Result<String> { |
418 | | let output = std::process::Command::new("git") |
419 | | .args(["tag", "-l", tag]) |
420 | | .output() |
421 | | .context("failed to run `git tag -l`")?; |
422 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
423 | | } |
424 | | |
425 | | fn git_log_latest_subject(&self) -> Result<String> { |
426 | | let output = std::process::Command::new("git") |
427 | | .args(["log", "-1", "--pretty=format:%s"]) |
428 | | .output() |
429 | | .context("failed to run `git log`")?; |
430 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
431 | | } |
432 | | |
433 | | fn git_fetch(&self) -> Result<()> { |
434 | | let status = std::process::Command::new("git") |
435 | | .arg("fetch") |
436 | | .status() |
437 | | .context("failed to run `git fetch`")?; |
438 | | if !status.success() { |
439 | | bail!("`git fetch` failed with status {status}"); |
440 | | } |
441 | | Ok(()) |
442 | | } |
443 | | |
444 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> { |
445 | | rev_list_count(&format!("HEAD..origin/{branch}")) |
446 | | } |
447 | | |
448 | | fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32> { |
449 | | rev_list_count(&format!("origin/{branch}..HEAD")) |
450 | | } |
451 | | |
452 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> { |
453 | | let status = std::process::Command::new("git") |
454 | | .args(["tag", "-a", tag, "-m", message]) |
455 | | .status() |
456 | | .context("failed to run `git tag -a`")?; |
457 | | if !status.success() { |
458 | | bail!("`git tag -a {tag}` failed with status {status}"); |
459 | | } |
460 | | Ok(()) |
461 | | } |
462 | | |
463 | | fn git_push_tag(&self, tag: &str) -> Result<()> { |
464 | | let status = std::process::Command::new("git") |
465 | | .args(["push", "origin", tag]) |
466 | | .status() |
467 | | .context("failed to run `git push origin <tag>`")?; |
468 | | if !status.success() { |
469 | | bail!("`git push origin {tag}` failed with status {status}"); |
470 | | } |
471 | | Ok(()) |
472 | | } |
473 | | |
474 | | fn read_cargo_toml(&self) -> Result<String> { |
475 | | std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml") |
476 | | } |
477 | | |
478 | | fn write_cargo_toml(&self, content: &str) -> Result<()> { |
479 | | std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml") |
480 | | } |
481 | | |
482 | | fn cargo_update_workspace(&self) -> Result<()> { |
483 | | let status = std::process::Command::new("cargo") |
484 | | .args(["update", "--workspace"]) |
485 | | .status() |
486 | | .context("failed to run `cargo update --workspace`")?; |
487 | | if !status.success() { |
488 | | bail!("`cargo update --workspace` failed with status {status}"); |
489 | | } |
490 | | Ok(()) |
491 | | } |
492 | | |
493 | | fn generate_changelog(&self) -> Result<()> { |
494 | | crate::changelog::generate_changelog(&crate::changelog::RealSystem) |
495 | | } |
496 | | |
497 | | fn prompt_user(&self, message: &str) -> Result<String> { |
498 | | use std::io::Write; |
499 | | print!("{message}"); |
500 | | std::io::stdout() |
501 | | .flush() |
502 | | .context("failed to flush stdout")?; |
503 | | let mut input = String::new(); |
504 | | std::io::stdin() |
505 | | .read_line(&mut input) |
506 | | .context("failed to read user input")?; |
507 | | Ok(input.trim().to_owned()) |
508 | | } |
509 | | } |
510 | | |
511 | | /// Determine the suggested next version and release type from the current branch. |
512 | | /// |
513 | | /// `main` -> minor bump; `*-maintenance` -> patch bump. |
514 | | /// |
515 | | /// # Arguments |
516 | | /// |
517 | | /// * `current` - Current version from `Cargo.toml`. |
518 | | /// * `branch` - Current git branch name. |
519 | | /// |
520 | | /// # Returns |
521 | | /// |
522 | | /// `(ReleaseType, next_version)`. |
523 | | /// |
524 | | /// # Errors |
525 | | /// |
526 | | /// Returns an error when `branch` is neither `main` nor ends with |
527 | | /// `-maintenance`. |
528 | 13 | pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> { |
529 | 13 | if branch == "main" { |
530 | 9 | let mut next = current.clone(); |
531 | 9 | next.minor += 1; |
532 | 9 | next.patch = 0; |
533 | 9 | Ok((ReleaseType::Minor, next)) |
534 | 4 | } else if branch.ends_with("-maintenance") { |
535 | 2 | let mut next = current.clone(); |
536 | 2 | next.patch += 1; |
537 | 2 | Ok((ReleaseType::Patch, next)) |
538 | | } else { |
539 | 2 | bail!( |
540 | | "must be on 'main' or a '*-maintenance' branch to prepare a release \ |
541 | | (current branch: {branch})" |
542 | | ) |
543 | | } |
544 | 13 | } |
545 | | |
546 | | /// Determine the release type by comparing two versions. |
547 | | /// |
548 | | /// # Arguments |
549 | | /// |
550 | | /// * `current` - The version before the release. |
551 | | /// * `next` - The version after the release. |
552 | | /// |
553 | | /// # Returns |
554 | | /// |
555 | | /// The most significant component that changed. |
556 | 4 | pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType { |
557 | 4 | if next.major > current.major { |
558 | 1 | ReleaseType::Major |
559 | 3 | } else if next.minor > current.minor { |
560 | 1 | ReleaseType::Minor |
561 | | } else { |
562 | 2 | ReleaseType::Patch |
563 | | } |
564 | 4 | } |
565 | | |
566 | | /// Rewrite the `[package].version` field in a `Cargo.toml` string. |
567 | | /// |
568 | | /// Uses `toml_edit` to preserve all existing formatting. |
569 | | /// |
570 | | /// # Arguments |
571 | | /// |
572 | | /// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`. |
573 | | /// * `new_version` - Version string to set. |
574 | | /// |
575 | | /// # Returns |
576 | | /// |
577 | | /// Updated TOML text. |
578 | | /// |
579 | | /// # Errors |
580 | | /// |
581 | | /// Returns an error if `cargo_toml_content` cannot be parsed as TOML. |
582 | 8 | pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> { |
583 | 8 | let mut doc: toml_edit::DocumentMut = cargo_toml_content |
584 | 8 | .parse() |
585 | 8 | .context("failed to parse Cargo.toml")?0 ; |
586 | 8 | doc["package"]["version"] = toml_edit::value(new_version); |
587 | 8 | Ok(doc.to_string()) |
588 | 8 | } |
589 | | |
590 | | /// Ensure the maintenance branch exists and is checked out before any release |
591 | | /// prepared from `main` (major/minor create + push the maintenance branch and |
592 | | /// then branch off `release-X.Y.Z`; a custom patch version typed on `main` |
593 | | /// switches to an existing maintenance branch and pushes the version bump |
594 | | /// directly). |
595 | | /// |
596 | | /// Fetches once, then handles the four |
597 | | /// (local exists, origin exists) combinations: |
598 | | /// |
599 | | /// - `(false, false)`: create the branch from the current HEAD (`main`) and |
600 | | /// push it to `origin`. |
601 | | /// - `(true, false)`: switch to the existing local branch and push it. |
602 | | /// - `(false, true)`: switch to the branch - git's DWIM creates a local |
603 | | /// tracking branch from `origin/<name>`. |
604 | | /// - `(true, true)`: switch to the local branch and verify it is neither |
605 | | /// behind nor ahead of `origin`. |
606 | | /// |
607 | | /// A failed `git fetch` is fatal here: every subsequent decision depends on |
608 | | /// `refs/remotes/origin/*` reflecting the remote's actual state, and a stale |
609 | | /// view can cause the wrong branch (create / push / checkout / fail-behind) |
610 | | /// to be taken. |
611 | | /// |
612 | | /// # Arguments |
613 | | /// |
614 | | /// * `system` - Injected I/O provider. |
615 | | /// * `maintenance_branch` - Name of the maintenance branch to ready. |
616 | | /// |
617 | | /// # Errors |
618 | | /// |
619 | | /// Returns an error if any git step fails or the local branch is behind or |
620 | | /// ahead of origin. |
621 | 8 | fn ensure_maintenance_branch_ready<S: ReleaseSystem>( |
622 | 8 | system: &S, |
623 | 8 | maintenance_branch: &str, |
624 | 8 | ) -> Result<()> { |
625 | 8 | println!("INFO - Fetching origin to check maintenance branch state"); |
626 | 8 | system |
627 | 8 | .git_fetch() |
628 | 8 | .context("failed to fetch from origin - cannot determine maintenance branch state")?1 ; |
629 | | |
630 | 7 | let local_exists = system.git_branch_exists_local(maintenance_branch)?0 ; |
631 | 7 | let origin_exists = system.git_branch_exists_origin(maintenance_branch)?0 ; |
632 | | |
633 | 7 | match (local_exists, origin_exists) { |
634 | | (false, false) => { |
635 | 1 | println!( |
636 | | "INFO - Maintenance branch {maintenance_branch} does not exist; \ |
637 | | creating from current HEAD and pushing to origin" |
638 | | ); |
639 | 1 | system.git_checkout_new_branch(maintenance_branch)?0 ; |
640 | 1 | system.git_push(&[ |
641 | 1 | "-u".to_owned(), |
642 | 1 | "origin".to_owned(), |
643 | 1 | maintenance_branch.to_owned(), |
644 | 1 | ])?0 ; |
645 | | } |
646 | | (true, false) => { |
647 | 1 | println!( |
648 | | "INFO - Maintenance branch {maintenance_branch} exists locally only; \ |
649 | | switching to it and pushing to origin" |
650 | | ); |
651 | 1 | system.git_checkout(maintenance_branch)?0 ; |
652 | 1 | system.git_push(&[ |
653 | 1 | "-u".to_owned(), |
654 | 1 | "origin".to_owned(), |
655 | 1 | maintenance_branch.to_owned(), |
656 | 1 | ])?0 ; |
657 | | } |
658 | | (false, true) => { |
659 | 1 | println!( |
660 | | "INFO - Maintenance branch {maintenance_branch} exists on origin only; \ |
661 | | creating a local tracking branch" |
662 | | ); |
663 | 1 | system.git_checkout(maintenance_branch)?0 ; |
664 | | } |
665 | | (true, true) => { |
666 | 4 | println!( |
667 | | "INFO - Maintenance branch {maintenance_branch} exists locally and on \ |
668 | | origin; switching to local branch" |
669 | | ); |
670 | 4 | system.git_checkout(maintenance_branch)?0 ; |
671 | 4 | let behind = system.git_rev_list_count_behind(maintenance_branch)?0 ; |
672 | 4 | if behind > 0 { |
673 | 1 | bail!( |
674 | | "local maintenance branch {maintenance_branch} is {behind} commit(s) \ |
675 | | behind origin - run `git pull` first" |
676 | | ); |
677 | 3 | } |
678 | | // Unpushed local commits would otherwise leak into the release PR |
679 | | // when we branch off `release-X.Y.Z` from here. |
680 | 3 | let ahead = system.git_rev_list_count_ahead(maintenance_branch)?0 ; |
681 | 3 | if ahead > 0 { |
682 | 1 | bail!( |
683 | | "local maintenance branch {maintenance_branch} is {ahead} commit(s) \ |
684 | | ahead of origin - push it before preparing a release" |
685 | | ); |
686 | 2 | } |
687 | | } |
688 | | } |
689 | 5 | Ok(()) |
690 | 8 | } |
691 | | |
692 | | /// Prepare a new release. |
693 | | /// |
694 | | /// Full workflow: |
695 | | /// 1. Verify working tree is clean. |
696 | | /// 2. Detect branch and suggest release type / next version. |
697 | | /// 3. Prompt user (accepts custom version input). |
698 | | /// 4. When releasing from `main`: ensure the target maintenance branch is |
699 | | /// ready (see [`ensure_maintenance_branch_ready`]). For a major/minor |
700 | | /// release this creates the branch when missing; for a patch release |
701 | | /// entered as a custom version it switches to the existing branch. |
702 | | /// Then, for a major/minor release, branch off a `release-X.Y.Z` branch |
703 | | /// for the version bump. For a patch release on a maintenance branch |
704 | | /// (or switched to one above), stay on that branch. |
705 | | /// 5. Update `Cargo.toml` version. |
706 | | /// 6. Run `cargo update --workspace`. |
707 | | /// 7. Generate changelog. |
708 | | /// 8. Commit the version bump. |
709 | | /// 9. For a major/minor release from `main`: push the `release-X.Y.Z` |
710 | | /// branch and open a GH PR against the maintenance branch. For a patch |
711 | | /// release: push directly to the maintenance branch. |
712 | | /// |
713 | | /// # Arguments |
714 | | /// |
715 | | /// * `system` - Injected I/O provider. |
716 | | /// |
717 | | /// # Errors |
718 | | /// |
719 | | /// Returns an error if any step fails. |
720 | 11 | pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> { |
721 | 11 | let status = system.git_status_porcelain()?0 ; |
722 | 11 | if !status.trim().is_empty() { |
723 | 1 | bail!("git working directory is not clean - commit or stash changes first:\n{status}"); |
724 | 10 | } |
725 | | |
726 | 10 | let current_branch = system.git_current_branch()?0 ; |
727 | 10 | let cargo_toml = system.read_cargo_toml()?0 ; |
728 | 10 | let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 |
729 | 10 | .parse() |
730 | 10 | .context("failed to parse current version as semver")?0 ; |
731 | | |
732 | 10 | println!("INFO - Current branch: {current_branch}"); |
733 | 10 | println!("INFO - Current version: {current_version}"); |
734 | | |
735 | 9 | let (suggested_type, suggested_version) = |
736 | 10 | suggest_next_version(¤t_version, ¤t_branch)?1 ; |
737 | | |
738 | 9 | let prompt = format!( |
739 | | "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: " |
740 | | ); |
741 | 9 | let answer = system.prompt_user(&prompt)?0 ; |
742 | | |
743 | 9 | let (next_version, actual_type) = |
744 | 9 | if answer.eq_ignore_ascii_case("n") || answer8 .eq_ignore_ascii_case("no") { |
745 | 1 | let custom_str = system.prompt_user(&format!( |
746 | 1 | "Enter custom version (current: {current_version}): " |
747 | 1 | ))?0 ; |
748 | 1 | if custom_str.is_empty() { |
749 | 0 | bail!("version cannot be empty"); |
750 | 1 | } |
751 | 1 | let custom: Version = custom_str |
752 | 1 | .parse() |
753 | 1 | .context("invalid version format - use semantic versioning (e.g. 1.2.3)")?0 ; |
754 | 1 | let release_type = determine_release_type(¤t_version, &custom); |
755 | 1 | (custom, release_type) |
756 | 8 | } else if answer.is_empty() |
757 | 8 | || answer.eq_ignore_ascii_case("y") |
758 | 0 | || answer.eq_ignore_ascii_case("yes") |
759 | | { |
760 | 8 | (suggested_version, suggested_type) |
761 | | } else { |
762 | 0 | bail!("invalid input - please enter Y or n"); |
763 | | }; |
764 | | |
765 | 9 | let releases_from_main = current_branch == "main"; |
766 | 9 | let opens_pr = |
767 | 9 | releases_from_main && matches!1 (actual_type8 , ReleaseType::Major | ReleaseType::Minor); |
768 | 9 | let maintenance_branch = if releases_from_main { |
769 | 8 | format!("{}.{}-maintenance", next_version.major, next_version.minor) |
770 | | } else { |
771 | 1 | current_branch.clone() |
772 | | }; |
773 | 9 | let pr_branch = opens_pr.then(|| format!7 ("release-{next_version}")); |
774 | | |
775 | 9 | println!("INFO - Preparing {actual_type} release: {current_version} -> {next_version}"); |
776 | 9 | println!("INFO - Maintenance branch: {maintenance_branch}"); |
777 | | |
778 | 9 | if releases_from_main { |
779 | 8 | ensure_maintenance_branch_ready(system, &maintenance_branch)?3 ; |
780 | 1 | } |
781 | | |
782 | 6 | if let Some(pr_branch_name4 ) = pr_branch.as_deref() { |
783 | 4 | println!("INFO - Creating release branch: {pr_branch_name}"); |
784 | 4 | system.git_checkout_new_branch(pr_branch_name)?0 ; |
785 | 2 | } |
786 | | |
787 | 6 | println!("INFO - Updating Cargo.toml version to {next_version}"); |
788 | 6 | let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())?0 ; |
789 | 6 | system.write_cargo_toml(&updated_cargo)?0 ; |
790 | | |
791 | 6 | println!("INFO - Updating Cargo.lock"); |
792 | 6 | system.cargo_update_workspace()?0 ; |
793 | | |
794 | 6 | println!("INFO - Generating changelog"); |
795 | 6 | system.generate_changelog()?0 ; |
796 | | |
797 | 6 | let commit_message = format!("Version {next_version}"); |
798 | 6 | println!("INFO - Committing: {commit_message}"); |
799 | 6 | system.git_add(&[ |
800 | 6 | "Cargo.toml".to_owned(), |
801 | 6 | "Cargo.lock".to_owned(), |
802 | 6 | "CHANGELOG.md".to_owned(), |
803 | 6 | "changelogging.toml".to_owned(), |
804 | 6 | ])?0 ; |
805 | | // Skip pre-commit hooks: the project's hook runs `cargo build --workspace |
806 | | // --all-targets`, which would try to replace the running xtask.exe and |
807 | | // fail on Windows with an access-denied error. |
808 | 6 | system.git_commit(&commit_message, true)?0 ; |
809 | | |
810 | 6 | if let Some(pr_branch_name4 ) = pr_branch.as_deref() { |
811 | 4 | println!("INFO - Pushing release branch: {pr_branch_name}"); |
812 | 4 | system.git_push(&[ |
813 | 4 | "-u".to_owned(), |
814 | 4 | "origin".to_owned(), |
815 | 4 | pr_branch_name.to_owned(), |
816 | 4 | ])?0 ; |
817 | | |
818 | 4 | println!("INFO - Opening PR against {maintenance_branch}"); |
819 | 4 | system.gh_pr_create(&maintenance_branch)?0 ; |
820 | | |
821 | 4 | println!( |
822 | | "INFO - Release {next_version} prepared on branch {pr_branch_name} \ |
823 | | with PR against {maintenance_branch}" |
824 | | ); |
825 | 4 | println!( |
826 | | "INFO - After the PR is merged, switch to {maintenance_branch}, \ |
827 | | pull, and run `cargo xtask create-release-tag` to tag the release" |
828 | | ); |
829 | | } else { |
830 | 2 | println!("INFO - Pushing to remote"); |
831 | 2 | system.git_push(&[])?0 ; |
832 | | |
833 | 2 | println!("INFO - Release {next_version} prepared on branch {maintenance_branch}"); |
834 | 2 | println!("INFO - Run `cargo xtask create-release-tag` to tag the release"); |
835 | | } |
836 | 6 | Ok(()) |
837 | 11 | } |
838 | | |
839 | | /// Create and push an annotated git tag for the current release version. |
840 | | /// |
841 | | /// Full workflow: |
842 | | /// 1. Verify on a maintenance branch. |
843 | | /// 2. Read version from `Cargo.toml`. |
844 | | /// 3. Check the tag does not already exist. |
845 | | /// 4. Verify the latest commit message is `"Version X.Y.Z"`. |
846 | | /// 5. Fetch from remote and check not behind. |
847 | | /// 6. Prompt user for confirmation. |
848 | | /// 7. Create annotated tag and push. |
849 | | /// |
850 | | /// # Arguments |
851 | | /// |
852 | | /// * `system` - Injected I/O provider. |
853 | | /// |
854 | | /// # Errors |
855 | | /// |
856 | | /// Returns an error if any validation step fails. |
857 | 6 | pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> { |
858 | 6 | let current_branch = system.git_current_branch()?0 ; |
859 | 6 | if !current_branch.ends_with("-maintenance") { |
860 | 1 | bail!( |
861 | | "must be on a maintenance branch to create a release tag \ |
862 | | (current branch: {current_branch}) - run `cargo xtask prepare-release` first" |
863 | | ); |
864 | 5 | } |
865 | | |
866 | 5 | let cargo_toml = system.read_cargo_toml()?0 ; |
867 | 5 | let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 ; |
868 | 5 | let version: Version = version_str |
869 | 5 | .parse() |
870 | 5 | .context("failed to parse version as semver")?0 ; |
871 | | |
872 | 5 | println!("INFO - Current branch: {current_branch}"); |
873 | 5 | println!("INFO - Version to tag: {version}"); |
874 | | |
875 | 5 | let existing_tag = system.git_tag_list(&version.to_string())?0 ; |
876 | 5 | if !existing_tag.trim().is_empty() { |
877 | 1 | bail!("tag {version} already exists"); |
878 | 4 | } |
879 | | |
880 | 4 | let commit_msg = system.git_log_latest_subject()?0 ; |
881 | 4 | let expected_msg = format!("Version {version}"); |
882 | 4 | if commit_msg != expected_msg { |
883 | 1 | bail!( |
884 | | "latest commit message does not match expected version commit\n\ |
885 | | expected: {expected_msg}\n\ |
886 | | actual: {commit_msg}\n\ |
887 | | run `cargo xtask prepare-release` first" |
888 | | ); |
889 | 3 | } |
890 | | |
891 | 3 | println!("INFO - Fetching latest changes from remote"); |
892 | 3 | if let Err(e0 ) = system.git_fetch() { |
893 | 0 | eprintln!("WARN - Failed to fetch from remote, continuing anyway: {e}"); |
894 | 3 | } |
895 | | |
896 | 3 | let behind = system.git_rev_list_count_behind(¤t_branch)?0 ; |
897 | 3 | if behind > 0 { |
898 | 1 | bail!("local branch is {behind} commit(s) behind remote - run `git pull` first"); |
899 | 2 | } |
900 | | |
901 | 2 | let answer = system.prompt_user(&format!( |
902 | 2 | "About to create and push tag '{version}'. Continue? [Y/n]: " |
903 | 2 | ))?0 ; |
904 | 2 | if answer.eq_ignore_ascii_case("n") || answer1 .eq_ignore_ascii_case("no") { |
905 | 1 | println!("INFO - Tag creation cancelled"); |
906 | 1 | return Ok(()); |
907 | 1 | } |
908 | | |
909 | 1 | let tag_message = format!("Version {version}"); |
910 | 1 | println!("INFO - Creating annotated tag: {version}"); |
911 | 1 | system.git_create_annotated_tag(&version.to_string(), &tag_message)?0 ; |
912 | | |
913 | 1 | println!("INFO - Pushing tag to remote"); |
914 | 1 | system.git_push_tag(&version.to_string())?0 ; |
915 | | |
916 | 1 | println!("INFO - Tag '{version}' created and pushed"); |
917 | 1 | println!("INFO - Check: https://github.com/whme/csshw/actions/workflows/release.yml"); |
918 | 1 | Ok(()) |
919 | 6 | } |
920 | | |
921 | | #[cfg(test)] |
922 | | #[path = "tests/test_release.rs"] |
923 | | mod tests; |